大家好,歡迎來到 Day 5!在昨天,我們為 Crew Up 建立了完整的多語言支援。今天,我們要來解決一個讓很多 Flutter 開發者頭痛的問題:導航管理。
你是否曾經因為 App 的導航變得混亂而苦惱?使用者點擊返回鍵時,App 卻跳到了奇怪的頁面?或者想要支援深層連結,卻發現傳統的 Navigator 根本不夠用?
在 Flutter 的導航世界裡,從最初的 Navigator 1.0 到令人卻步的 Navigator 2.0,再到現在的 go_router,就像是從腳踏車演進到高鐵的過程。今天,我們要用 go_router 為 Crew Up 打造一個清晰、好維護的導航系統。
我們將重點探討:
Navigator 1.0 (2017-2021)
Navigator.push()
, Navigator.pop()
Navigator 2.0 (2021-2022)
go_router (2022-至今)
在我們的「Crew Up!」專案中,我們選擇了 go_router
作為導航解決方案,實際使用後覺得以下優點:
🎯 解決了我們的真實痛點
說到路由管理,我們一開始也是新手,什麼都往 AppRouter
裡面塞。結果就是一個檔案裡面有一堆硬編碼的字串,修改路由時要找半天,還很容易拼錯字。
後來我們學乖了,把這些常數分門別類整理好。這就像是把原本散落一地的樂高積木,按照顏色和大小分類收納:
// lib/app/config/router/app_router.dart
// (imports omitted)
/// 路由路徑常數
class AppRoutePaths {
AppRoutePaths._();
// 主要路由
static const String home = '/';
static const String login = '/login';
// 註冊流程路由
static const String register = '/register';
static const String registerIntroduction = '/register/introduction';
static const String registerInterests = '/register/interests';
static const String registerGoals = '/register/goals';
// 活動相關路由
static const String activityDetail = '/activity/:activityId';
static const String activityList = '/activities';
static const String activityListWithCategory = '/activities/:category';
// 創建活動流程路由
static const String createActivity = '/create-activity';
static const String createActivityPreview = '/create-activity/preview';
// 訊息相關路由
static const String messageList = '/messages';
static const String messageChat = '/messages/:chatId';
}
/// 路由名稱常數
class AppRouteNames {
AppRouteNames._();
// 主要路由名稱
static const String home = 'home';
static const String login = 'login';
// 註冊流程路由名稱
static const String register = 'register';
static const String registerIntroduction = 'register_introduction';
static const String registerInterests = 'register_interests';
static const String registerGoals = 'register_goals';
// 活動相關路由名稱
static const String activityDetail = 'activity_detail';
static const String activityList = 'activity_list';
static const String activityListCategory = 'activity_list_category';
}
/// 路由參數名稱常數
class AppRouteParams {
AppRouteParams._();
static const String activityId = 'activityId';
static const String category = 'category';
static const String chatId = 'chatId';
}
🎯 常數抽離的優勢:
AppRoutePaths.home
比使用字串更清楚接下來我們發現,我們的路由其實有很清楚的家族關係。比如註冊流程就是 /register
、/register/introduction
、/register/interests
這樣的階層結構。
與其把它們當作平行的路由,不如讓它們真的成為「一家人」,用巢狀路由來表達這種關係:
// lib/app/config/router/app_router.dart
static GoRouter get router => GoRouter(
initialLocation: AppRoutePaths.home,
routes: [
// 首頁路由
GoRoute(
path: AppRoutePaths.home,
name: AppRouteNames.home,
builder: (context, state) => const HomeScreen(),
),
// 註冊流程路由(巢狀結構)
GoRoute(
path: AppRoutePaths.register,
name: AppRouteNames.register,
builder: (context, state) => const RegisterScreen(),
routes: [
// 註冊介紹頁面
GoRoute(
path: 'introduction',
name: AppRouteNames.registerIntroduction,
builder: (context, state) => const RegisterIntroductionScreen(),
),
// 註冊興趣頁面
GoRoute(
path: 'interests',
name: AppRouteNames.registerInterests,
builder: (context, state) => const RegisterInterestsScreen(),
),
// 註冊目標頁面
GoRoute(
path: 'goals',
name: AppRouteNames.registerGoals,
builder: (context, state) => const RegisterGoalsScreen(),
),
],
),
// 活動相關路由(巢狀結構)
GoRoute(
path: AppRoutePaths.activityList,
name: AppRouteNames.activityList,
builder: (context, state) => const ActivityListScreen(),
routes: [
// 活動列表頁面路由(帶類別)
GoRoute(
path: ':category',
name: AppRouteNames.activityListCategory,
builder: (context, state) {
final categoryString = extractRequiredParam(state, AppRouteParams.category);
final category = ActivityCategoryExtension.fromString(categoryString);
return ActivityListScreen(initialCategory: category);
},
),
],
),
// 訊息相關路由(巢狀結構)
GoRoute(
path: AppRoutePaths.messageList,
name: AppRouteNames.messageList,
builder: (context, state) => const MessageListScreen(),
routes: [
// 訊息聊天頁面
GoRoute(
path: ':chatId',
name: AppRouteNames.messageChat,
builder: (context, state) {
final chatId = extractRequiredParam(state, AppRouteParams.chatId);
return MessageChatScreen(chatId: chatId);
},
),
],
),
],
);
💡 巢狀路由的優點:
/register
下的所有頁面添加統一的重導向會很方便一開始我們的錯誤處理很粗糙,就是丟個 Exception 然後等著看會發生什麼事。後來發現這樣不行,使用者體驗很差,而且除錯也很困難。
所以我們決定為路由錯誤建立一個專門的檔案,讓錯誤處理變得更精確:
// lib/app/config/router/router_exceptions.dart
/// 路由相關的例外類型
///
/// 包含所有與路由導航相關的自定義例外,
/// 用於提供更精確的錯誤處理和除錯資訊。
library;
/// 路由參數缺失例外
///
/// 當必要的路由參數缺失或為空時拋出此例外。
/// 包含缺失的參數名稱,用於精確的錯誤處理。
class MissingParameterException implements Exception {
/// 缺失的參數名稱
final String parameterName;
/// 建立新的參數缺失例外
const MissingParameterException(this.parameterName);
@override
String toString() => 'Missing required route parameter: $parameterName';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MissingParameterException &&
parameterName == other.parameterName;
@override
int get hashCode => parameterName.hashCode;
}
我們簡化了 extractRequiredParam
的簽名,讓它更簡潔:
// lib/app/config/router/app_router.dart
/// 安全地提取路由參數
static String extractRequiredParam(GoRouterState state, String paramName) {
final value = state.pathParameters[paramName];
if (value == null || value.isEmpty) {
throw MissingParameterException(paramName);
}
return value;
}
在 errorBuilder 中,我們使用型別檢查而非字串比對:
// lib/app/config/router/app_router.dart
errorBuilder: (context, state) => ErrorHandlerService.buildErrorPage(
context,
state,
onRetry: () {
final error = state.error;
if (error is MissingParameterException) {
// 基於參數名稱決定適當的Fallback
switch (error.parameterName) {
case AppRouteParams.activityId:
context.go(AppRoutePaths.home);
break;
case AppRouteParams.chatId:
context.go(AppRoutePaths.messageList);
break;
case AppRouteParams.category:
context.go(AppRoutePaths.activityList);
break;
default:
context.go(AppRoutePaths.home);
}
} else {
context.go(AppRoutePaths.home);
}
},
),
🚀 錯誤處理的改進:
我們之前犯了一個錯誤:覺得給開發者更多選擇是好事,所以幾乎每個路由都提供了 navigateTo...
(go) 和 pushTo...
(push) 兩種版本。
結果發現這樣反而造成困擾,每次導航時都要思考:「我該用 go 還是 push?」就像是餐廳菜單太多選項,反而不知道要點什麼。
後來我們決定變得「有主見」一點,根據業務邏輯直接決定該用哪種導航方式:
// lib/app/config/router/app_router.dart
/// 有主見的路由導航擴展
/// 為每個業務操作提供語意明確的導航方法
extension AppRouterNavigation on BuildContext {
// --- 主要導航 (使用 go) ---
/// 前往首頁
///
/// 使用 [go] 清空導航堆疊,直接導航到應用程式主頁
void goHome() => go(AppRoutePaths.home);
/// 前往登入頁面
void goLogin() => go(AppRoutePaths.login);
/// 前往訊息列表
void goMessageList() => go(AppRoutePaths.messageList);
/// 前往活動列表
void goActivityList() => go(AppRoutePaths.activityList);
// --- 註冊流程導航 (使用 push) ---
/// 開始註冊流程
void pushRegister() => push(AppRoutePaths.register);
/// 進入註冊介紹頁面
void pushRegisterIntroduction() => push(AppRoutePaths.registerIntroduction);
/// 進入註冊興趣頁面
void pushRegisterInterests() => push(AppRoutePaths.registerInterests);
/// 進入註冊目標頁面
void pushRegisterGoals() => push(AppRoutePaths.registerGoals);
// --- 詳情頁面導航 (使用 push) ---
/// 查看活動詳情
///
/// 推入活動詳情頁面,保留導航歷史讓使用者可以返回
///
/// [activityId] 要查看的活動 ID
void pushActivityDetail(String activityId) {
pushNamed(
AppRouteNames.activityDetail,
pathParameters: {AppRouteParams.activityId: activityId},
);
}
/// 進入訊息聊天
///
/// [chatId] 要進入的聊天 ID
void pushMessageChat(String chatId) {
pushNamed(
AppRouteNames.messageChat,
pathParameters: {AppRouteParams.chatId: chatId},
);
}
// --- 創建活動流程 (使用 push) ---
/// 開始創建活動
void pushCreateActivity() => push(AppRoutePaths.createActivity);
/// 預覽創建的活動
void pushCreateActivityPreview() => push(AppRoutePaths.createActivityPreview);
// --- 特殊導航 ---
/// 瀏覽特定分類的活動
void pushActivityListWithCategory(ActivityCategory category) {
pushNamed(
AppRouteNames.activityListCategory,
pathParameters: {AppRouteParams.category: category.name},
);
}
}
🎯 有主見的設計原則:
在我們專案中,活動詳情的導航方式經歷了重要的改進。我們從傳遞整個 Activity
物件改為傳遞 activityId
:
// lib/features/home/presentation/screens/index_screen.dart
// (imports omitted)
class IndexScreen extends ConsumerStatefulWidget {
// 改進後的導航方式
void _onActivityTap(Activity activity) {
// 使用 ID 導航,而不是傳遞整個物件
context.pushActivityDetail(activity.id);
}
void _onPopularActivityTap(Activity activity) {
context.pushActivityDetail(activity.id);
}
void _onViewMoreTap() {
context.goActivityList();
}
void _onThemeTap(ActivityCategory category) {
context.pushActivityListWithCategory(category);
}
}
活動詳情頁面現在接收 activityId
而非整個物件:
// lib/features/activity/presentation/screens/activity_detail_screen.dart
// (imports omitted)
/// 活動詳情頁面 - 使用路由參數版本
class ActivityDetailScreen extends ConsumerWidget {
final String activityId;
const ActivityDetailScreen({super.key, required this.activityId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 根據 ID 從狀態管理中取得活動資料
final activityAsync = ref.watch(activityByIdProvider(activityId));
return Scaffold(
backgroundColor: const Color(0xFFfff8eb),
appBar: _buildAppBar(context),
body: activityAsync.when(
data: (activity) => _buildContent(context, ref, activity),
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.actionPrimary),
),
error: (error, stackTrace) => _buildErrorContent(context),
),
);
}
}
✅ ID 導航的優勢:
相信很多人都有這個疑問:什麼時候用 go
?什麼時候用 push
? 這個問題困擾了我們很久,直到我們建立了「有主見的導航擴展」,才算是徹底解決。
✅ 適合用 go
的情況:
// 實際專案中的範例
void onLoginSuccess() {
// 登入成功後清空導航堆疊,直接到首頁
context.goHome();
}
void onBottomNavTap(int index) {
// 底部導航切換,不需要保留前一頁
switch (index) {
case 0: context.goHome(); break;
case 1: context.goActivityList(); break;
}
}
✅ 適合用 push
的情況:
// 實際專案中的範例
void onActivityTap(Activity activity) {
// 查看活動詳情,使用者可能要返回列表
context.pushActivityDetail(activity.id);
}
void onStartRegistration() {
// 開始註冊流程
context.pushRegister();
}
經過幾個月的實際使用,我們對 go_router 和這套導航系統有了更深的體會:
🎯 那些真的有用的東西:
今天我們為 Crew Up 打造了一個從基礎到完整的導航系統。從一開始的路由常數大亂鬥,到最後的有主見導航擴展,每一步都是為了解決實際開發中遇到的問題。
💡 這次經驗教會我們什麼?
好的架構是慢慢長出來的:我們的路由系統不是一天建成的,而是在開發過程中逐步優化的結果
有時候「有主見」是好事:與其讓開發者每次都做選擇,不如根據業務邏輯直接做出最合適的決定
重構是值得的投資:雖然重構常數分類看起來很瑣碎,但長期來看絕對值得
錯誤處理要考慮使用者體驗:技術上的錯誤不應該直接暴露給使用者
文件註解是未來的自己會感謝你的事:幾個月後回來看程式碼,你會感謝當初寫註解的自己
🚀 實際收穫
現在我們有了一個清晰、好維護的導航系統。新加入團隊的開發者可以很快理解路由結構,使用者也能享受流暢的導航體驗。如果你在實作過程中遇到困難,別擔心,這很正常。重要的是一步一步來,慢慢改進。
有了這個穩固的導航基礎,我們可以專心處理更複雜的功能。明天,我們要來面對另一個經典難題:狀態管理。Day 6 見!